import swipeEvents from '../../js/swipeEvents.js'
import createProps from '../../js/createProps.js'
import {
recursiveSwitchAriaHidden,
setFirstAndLastFocusable,
focusin
} from '../../js/scopedKeyboardNav.js'
import {
TIME_FAST,
EVENT_MODAL_OPENED,
EVENT_MODAL_CLOSED,
EVENT_MODAL_OPEN,
EVENT_MODAL_CLOSE,
EVENT_MODAL_KEY_DOWN,
EVENT_SWIPED_DOWN,
KEY_ESCAPE
} from '../../js/constants.js'
import breakpoint from '../../js/breakpoints.js'
import { dispatchEvent, readEvent } from '../../js/events.js'
const STATE_OPEN = 'isOpen'
const SCROLL_LOCK = 'scroll-lock'
class Modal extends HTMLElement {
constructor() {
super()
this.classList.add('u-modal')
// Init properties
this.ariaModal = null
this.aaLabelClose = "Close modal"
// Events
this.boundOpen = this.open.bind(this)
this.boundClose = this.close.bind(this)
this.boundEscapeClose = this.escapeClose.bind(this)
this.boundFocus = this.focus.bind(this)
this.boundFocusin = this.focusin.bind(this)
}
connectedCallback() {
this.style.display = 'none'
createProps(this, true)
this.ariaModal = false
this.render()
}
render() {
if (this.hasEvent) {
this.unbindEvent()
}
if (this.slot === '') {
this.bindEvent()
this.classList.remove(STATE_OPEN)
this.isOpen = false
return false
}
// FYI : The role is dialog, not alertdialog (only used for alerts / warnings ...)
this.setAttribute('role', 'dialog')
// Set the popin aria-hidden by default
this.setAttribute('aria-hidden', 'true')
// Set the modal style ( DEFAULT / FULL )
if (!this.variant) {
this.setAttribute('variant', 'default')
}
// Be careful to not remove the title div, even if empty, because it holds on purpose the top gradient for scroll effect
this.innerHTML = `
${this.heading && `
${this.heading}
`}${this.slot}
${breakpoint.is.mobile ? '' : ''}
`
this.buttonClose = this.querySelector('.u-close button')
this.box = this.querySelector('.u-modal-box')
this.section = this.querySelector('.u-inner > section')
this.footer = this.querySelector('.u-inner > footer')
// Instanciate overlay
this.renderOverlay()
// Wait for CSS to be downloaded (avoid flashing screen with bad modal skin)
setTimeout(() => {
this.style.display = 'inherit'
}, 100)
this.bindEvent()
}
moveFooter() {
const footer = this.section.querySelector("footer")
if (footer) {
this.footer.remove()
this.section.after(footer)
this.footer = footer
}
// Set the first and last focusable elements only after moving footer
setFirstAndLastFocusable(this.box)
}
renderOverlay() {
// Instanciate overlay
const CustomElement = window.customElements.get("u-overlay")
this.overlay = new CustomElement()
document.body.appendChild(this.overlay)
}
addLock() {
document.documentElement.classList.add(SCROLL_LOCK)
this.overlay?.open()
}
removeLock() {
document.documentElement.classList.remove(SCROLL_LOCK)
this.overlay?.close()
}
switchAriaModal() {
this.ariaModal = !this.ariaModal
recursiveSwitchAriaHidden(this, this.ariaModal)
}
async open(e) {
// Check if it's right popin to open, if not returns
const event = readEvent(e)
if (event.id !== this.id) {
return
}
// Transitionend listener is fired multiple times (fore each css property)
// so we need to instantiate it only when we open or close and then we remove it on focusin
this.addEventListener('transitionend', this.boundFocus)
document.addEventListener('focusin', this.boundFocusin)
this.openingSource = document.activeElement
this.addLock()
this.switchAriaModal()
this.setAttribute('aria-hidden', 'false')
this.isOpen = !this.isOpen
await new Promise(resolve => {
setTimeout(() => {
this.classList.add(STATE_OPEN)
resolve()
}, TIME_FAST)
})
dispatchEvent({
eventName: EVENT_MODAL_OPENED,
args: { id: this.id }
})
this.moveFooter()
}
close(e) {
if (e) {
e.cancelBubble = true
if (e.stopPropagation) {
e.stopPropagation()
}
}
// Transitionend listener is fired multiple times (fore each css property)
// so we need to instantiate it only when we open or close, and then we remove it on focusin
this.addEventListener('transitionend', this.boundFocus)
document.removeEventListener('focusin', this.boundFocusin)
this.removeLock()
this.switchAriaModal()
this.setAttribute('aria-hidden', 'true')
this.isOpen = !this.isOpen
this.classList.remove(STATE_OPEN)
dispatchEvent({
eventName: EVENT_MODAL_CLOSED,
args: { id: this.id }
})
}
focus() {
this.removeEventListener('transitionend', this.boundFocus)
if (this.isOpen) {
// Set focus on the close button by default is an AA standard
this.buttonClose.focus()
} else {
// Put back the focus on the button that opened the modal
this.openingSource.focus()
}
}
focusin(e) {
focusin(e, this.box, true)
}
escapeClose(e) {
if (e.key === KEY_ESCAPE && this.isOpen) {
this.close(e)
}
}
swipeDown(e) {
if (this.isOpen) {
this.close(e)
}
}
bindEvent() {
this.hasEvent = true
this.buttonClose?.addEventListener('click', this.boundClose)
window.addEventListener(EVENT_MODAL_OPEN, this.boundOpen)
window.addEventListener(EVENT_MODAL_CLOSE, this.boundClose)
document.addEventListener(EVENT_MODAL_KEY_DOWN, this.boundEscapeClose)
if (breakpoint.is.mobile) {
swipeEvents(window, document, this)
this.boundSwipeDown = this.swipeDown.bind(this)
document.addEventListener(EVENT_SWIPED_DOWN, this.boundSwipeDown)
}
}
unbindEvent() {
this.hasEvent = false
this.buttonClose?.removeEventListener('click', this.boundClose)
window.removeEventListener(EVENT_MODAL_OPEN, this.boundOpen)
window.removeEventListener(EVENT_MODAL_CLOSE, this.boundClose)
document.removeEventListener(EVENT_MODAL_KEY_DOWN, this.boundEscapeClose)
if (this.boundSwipeDown) {
document.removeEventListener(EVENT_SWIPED_DOWN, this.boundSwipeDown)
this.boundSwipeDown = null
}
}
disconnectedCallback() {
if (!this.hasEvent) {
return
}
this.unbindEvent()
}
}
customElements.get('u-modal') || customElements.define('u-modal', Modal)
export default Modal